iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0
Modern Web

用 Effect 實現產品級軟體系列 第 20

[學習 Effect Day20] Effect 服務管理(一)

  • 分享至 

  • xImage
  •  

想像你在應用程式裡到處傳 databaseServiceloggingService,很快地每個函式都在接收、轉傳一堆 Service,變得又厚又難測。Effect 的做法是:把「誰需要什麼服務」寫進型別,由編譯器幫你把關與組裝。

在 Effect 中服務(Services)是什麼?

在程式設計中,Service 指的是提供特定功能的可重複使用元件,可用於應用程式的不同部分。像是 Database、Logger、Http 都是典型的 Service。這種設計讓業務邏輯能依賴「功能本身」,而不是自己實現某個特定的實作,程式就能解耦。因此更容易替換、測試與維護,不必改動核心邏輯。

手動傳參與「大 Context」的困難

最直覺的作法是把 Service 當參數一路往下傳。如下方程式碼所示:

async function processUserOrder(
  userId: string,
  orderId: string,
  userService: UserService,
  databaseService: DatabaseService,
  loggerService: LoggerService,
  emailService: EmailService,
  cacheService: CacheService,
  paymentService: PaymentService,
  notificationService: NotificationService
) {
  // 實際只用了其中幾個服務
  const user = await userService.getUser(userId);
  const order = await databaseService.query(`SELECT * FROM orders WHERE id = ${orderId}`);
  
  // 呼叫下層函式時,又要傳遞所有參數
  return await sendOrderConfirmation(
    user, order, userService, databaseService, 
    loggerService, emailService, cacheService, 
    paymentService, notificationService
  );
}

你會發現上層函式為了呼叫下層,不得不接受很多自己其實不使用的參數,導致「參數膨脹」。

另一種常見作法是塞一個大 Context

// 把所有服務都塞進一個大 Context
interface AppContext {
  userService: UserService;
  databaseService: DatabaseService;
  loggerService: LoggerService;
  emailService: EmailService;
  cacheService: CacheService;
  paymentService: PaymentService;
  notificationService: NotificationService;
  configService: ConfigService;
  metricsService: MetricsService;
  auditService: AuditService;
  // ... 還有更多服務
}

// 因為 processUserOrder 接受的 context 包含所有服務,所以得確保 context 中所有服務在執行期都有妥善提供。
async function processUserOrder(userId: string, orderId: string, context: AppContext) {
  
  const user = await context.userService.getUser(userId);
  const order = await context.databaseService.query(`SELECT * FROM orders WHERE id = ${orderId}`);
  // 這個函式到底需要哪些服務?從 Function Signature 看不出來,閱讀不易
  return await sendOrderConfirmation(user, order, context);
}

function testProcessUserOrder() {
  const mockContext: AppContext = {
    userService: mockUserService,
    databaseService: mockDatabaseService,
    // 必須提供所有服務,即使測試用不到 => 測試變得複雜
    loggerService: mockLoggerService,
    emailService: mockEmailService,
    cacheService: mockCacheService,
    paymentService: mockPaymentService,
    notificationService: mockNotificationService,
    configService: mockConfigService,
    metricsService: mockMetricsService,
    auditService: mockAuditService,
  };
}

雖然少傳了參數,但函式的真正需求被藏起來,初始化也容易在執行期才爆錯。兩者都讓複合函數(functional composition)與測試變得很不容易。

Effect 的解法(型別化環境)

Effect 透過「型別化的環境 Requirements」來描述一段運算需要哪些 Services。

Effect 型別:Effect<Success, Error, Requirements>

  • Requirements 是服務需求,沒需求就是 never

每段 Effect 只宣告自己要的功能,最後在應用邊界一次性 provide。少了「大 Context 的隱性耦合」、也避免「手動傳參的參數滑坡」。更關鍵的是:如果你忘了提供某個 Service,型別檢查會在編譯期就提醒你,而不是到執行期才出錯。

  • 以前:手動傳參數 → 容易耦合、難維護。
  • 現在:在 Effect 的型別上宣告需求 → 編譯器強制你在執行前完整提供,錯不了。

那具體要如何做呢?我們一起看下去吧~

步驟 1:定義一個 Service(Random)

定義一個 Service 時,我們需要使用 Effect 中的 ContextContext 是一個型別安全的服務容器,用來儲存和提供服務實例。當我們提供具體的實作時,Effect 會從 Context 中提取對應的服務。你可以將 Context 想像成一個服務註冊表,用 Tag 來索引服務。

type Context = Map<Tag, Service>

我們用一個產生隨機數的 Service 當例子🌰。

import { Context, Effect } from "effect";

// 以唯一字串識別建立 Tag
class Random extends Context.Tag("MyRandomService")<
  Random, // 這裡的 Random 就是指向這個 class 自己
  // service 會回傳一個 number 的 Effect
  { readonly next: Effect.Effect<number> }
>() {}

要特別注意的是 Tag 需要是唯一識別字串,確保同一識別字串在整個程式或熱重載後還是會對應到同一個服務實例。

步驟 2:使用 Service

你可以把 Tag 當作一個可 yield* 的「抽取服務」效果來用。

使用 Effect.gen(可讀性最高)

//      ┌─── Effect<void, never, Random>
//      ▼
const program = Effect.gen(function*() {
  const random = yield* Random
  const randomNumber = yield* random.next
  console.log(`random number: ${randomNumber}`)
})

在執行前 Effect.runSync(program) 就會先在編輯器上看到:
Missing 'Random' in the expected Effect context.effect(1)

若直接執行 Effect.runSync(program),也會出現類似的錯誤,如下:

node:internal/modules/run_main:123
    triggerUncaughtException(
    ^

Error: Service not found: MyRandomService (defined at <anonymous> (/Users/eric/personal-project/effect-app/src/day20/Program.ts:4:51))
.....

這些錯誤都是在告訴我們的 program 缺少 Random 服務。

步驟 3:提供 Service 實作

使用Effect.provideService綁定服務到 program 上並提供服務的實作Effect.sync(() => Math.random())

// Providing the implementation
//
//      ┌─── Effect<void, never, never>
//      ▼
const runnable = Effect.provideService(program, Random, {
  next: Effect.sync(() => Math.random())
})

Effect.runSync(runnable) 

// 輸出:
// random number: 0.8955022180738443

綁定後你可以發現,原本 program 是有 Requirements 的,現在變成 never。所以可以直接執行 Effect.runSync(runnable)了。

一次提供多個 Services

宣告第二個 Service:Logger

// 新定義一個 Logger 服務
class Logger extends Context.Tag("MyLoggerService")<
  Logger,
  { readonly log: (message: string) => Effect.Effect<void> }
>() {}

// 將 program 改成有用到 Random 與 Logger 兩個服務
const program = Effect.gen(function*() {
  const random = yield* Random
  const logger = yield* Logger

  const randomNumber = yield* random.next

  yield* logger.log(String(randomNumber))
})

同時綁定 Random 與 Logger 服務

// program 的需求是 Random | Logger
const runnable = program.pipe(
  Effect.provideService(Random, {
    next: Effect.sync(() => Math.random())
  }),
  Effect.provideService(Logger, {
    log: (message) => Effect.sync(() => console.log(`[${new Date().toLocaleString()}] ${message}`))
  })
)

Effect.runSync(runnable)

// 輸出:
// [9/30/2025, 2:23:28 AM] 0.32955400245685196

也可以先組好 Context,再一次性綁定所有服務

我個人比較喜歡這個 pattern。乾淨 ❤️。

const context = Context.empty().pipe(
  Context.add(Random, { next: Effect.sync(() => Math.random()) }),
  Context.add(Logger, {
    log: (message) => Effect.sync(() => console.log(`[${new Date().toLocaleString()}] ${message}`))
  })
)

const runnable = Effect.provide(program, context)
Effect.runSync(runnable)

// 輸出:
// [9/30/2025, 2:30:16 AM] 0.41678327901764756

可選服務:Effect.serviceOption

當服務「可有可無」時,用 serviceOption。它回傳 Option<Service>,未提供時是 None。嘿嘿~😌還好Option的觀念我們之前的文章有講,沒有偷懶。所以這邊我就不贅述啦!

import { Effect, Context, Option } from "effect"

const program = Effect.gen(function*() {
  const maybeRandom = yield* Effect.serviceOption(Random)
  const randomNumber = Option.isNone(maybeRandom)
    ? -1 :
    yield* maybeRandom.value.next
  console.log(randomNumber)
})

// 未提供 Random → 輸出 -1
Effect.runSync(program)

// 有提供 Random → 輸出真的隨機數
Effect.runSync(
  Effect.provideService(program, Random, {
    next: Effect.sync(() => Math.random())
  })
)

// 輸出:
// -1
// 0.7736486052039062

重點:因為有處理缺少Random服務的情況,program 的 Requirements 是 never

抽取服務型別:Context.Tag.Service

可以透過 Context.Tag.Service 抽出服務回傳的型別

import { Context } from "effect";

type RandomShape = Context.Tag.Service<Random>;
// 等同於:{ readonly next: Effect.Effect<number> }

做一個 Mock 服務進行測試超簡單

因為需求寫在型別上,測試時直接提供假的 Service 即可。

// 被測函式:回傳一個隨機數(方便斷言)
const drawNumber = Effect.gen(function*() {
  const random = yield* Random
  return yield* random.next
})

// 測試:固定回傳 0.5
const testEffect = drawNumber.pipe(
  Effect.provideService(Random, { next: Effect.succeed(0.5) })
)

console.log(Effect.runSync(testEffect))
// 輸出:0.5

常見錯誤與排錯

  • 忘了提供服務
    • 症狀:TS 提示 Effect<..., Random | ...> 不能執行。
    • 解法:用 provideServiceprovide(整包 Context)補齊。
  • Tag 識別非唯一
    • 症狀:熱重載後行為怪異或多實例。
    • 解法:確保 Context.Tag("UniqueId") 的字串在專案中全域唯一。
  • 介面洩漏相依(這個官方文件有提到,未來會詳細說明)
    • 症狀:某 Service 的方法型別意外需要另一個 Service。
    • 解法:把相依移到 Layer 的建構過程,不要放在對外介面。
  • 真的可能缺席的服務
    • 解法:改用 Effect.serviceOption(Tag),處理 Option 即可。

總結

本文介紹了 Effect 如何透過型別化的服務需求來解決傳統依賴注入的問題,避免手動傳參數或大 Context 的缺點。Effect 讓每段程式只宣告自己需要的服務,編譯器會在編譯期就檢查你是否提供了所有必要的服務,而不是等到執行期才出錯。

全文透過產出隨機數的服務作為例子,展示了如何定義、使用和提供服務,以及如何處理可選服務和進行測試。這種方式讓程式可以更模組化、更容易測試。

下一篇我們將介紹 Layer,來讓服務中還依賴其他服務的情況,對外介面還是可以保持乾淨(Requirements 為 never)。

參考資料


上一篇
[學習 Effect Day19] Effect 進階錯誤管理 (五)
下一篇
[學習 Effect Day21] Effect 服務管理(二)
系列文
用 Effect 實現產品級軟體22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言